Skip to content

fix(studio): target one element when adding a keyframe at the playhead - #2847

Merged
miguel-heygen merged 16 commits into
mainfrom
fix/studio-playhead-keyframe-target
Jul 28, 2026
Merged

fix(studio): target one element when adding a keyframe at the playhead#2847
miguel-heygen merged 16 commits into
mainfrom
fix/studio-playhead-keyframe-target

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

Adding a keyframe to an element with no id wrote a tween that animates every one of its siblings.

Bugs fixed

  1. "Add keyframe at playhead" collapses sibling timeline rows into one. On an element with no id, the write used the bare class buildStableSelector hands back, so one add on a .group element wrote tl.to(".group", ...). That tween animates all five siblings, and resolveSelectorElementIds reads it back as all five, collapsing their timeline rows into a single row. It survived a reload, so the written file stayed un-editable.
  2. The one-element narrowing was undone by the next edit. Found in review of this PR. replace-with-keyframes deletes the tween and adds it back, so its targetSelector is a full rewrite of the target, and the paths in useEnableKeyframes derived that string from the selection. Promoting a set on a tween already narrowed to #scene > div:nth-child(3) rewrote it back to .group, re-widening it onto every sibling.
  3. A detached element re-authored the exact broken selector. Found in review of this PR. writeTargetSelector returned the selection's bare selector whenever the structural walk failed, including when a live DOM was there to check against. An element re-parented or removed between selecting and committing takes that path.

Why

This is data loss in the user's own source file, not a display bug. One click silently rewrites five elements' animation and leaves the composition in a state Studio itself can no longer edit.

How

Add writeTargetSelector, the write-side counterpart to selectorFromSelection (which must keep returning the exact string findTweenAtTime compares against). It resolves the element's own identity to a selector addressing exactly one element, verifying every rung against the live document:

#id , else [data-hf-id="..."] , else the selection's own selector when it is already unique, else a :nth-child path anchored on the nearest identifiable ancestor.

Applied to the two paths that author a NEW tween: the no-animation branch of useEnableKeyframes, and commitKeyframeAtTimeImpl.

For bug 2, the replace-with-keyframes paths now keep the target the tween already writes (existingTweenTargetSelector), which is what eight sibling commit modules already do. Re-deriving it discarded whatever the author aimed at AND re-widened a tween a previous add had narrowed. The selection is the fallback only for a target the parser could not resolve statically.

For bug 3, writeTargetSelector returns null when a live DOM was available and no rung addressed one element. A failed walk against a live document is evidence, not the absence of it. Callers decide: the add-keyframe path already has a graceful no-animation fallback, and the paths that cannot afford to drop a user edit opt back in with ?? selectorFromSelection where the trade is visible in the call. The bare selector still comes back when there is no DOM to disambiguate against.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Studio suite green on this branch: 3042 passing, 0 failures. Typecheck, lint and format clean.

Not covered

  • Fifth of a 7-PR stack.
  • useGestureCommit and useGsapAnimationOps also pass a non-tween-derived target to replace-with-keyframes. In useGestureCommit the tween was found by string equality against that same selector, so the two agree by construction; useGsapAnimationOps takes the string as a parameter and its callers decide. Neither is re-widening today, so both are left alone.
  • This PR wires the fix into the two paths the QA sweep reproduced. The same defect is reachable from six more writers (add-animation button, drag, resize, rotate, gesture recording, property panel), fixed at the top of this stack once each site had been reproduced individually.

Known ceiling

The :nth-child rung is computed against the preview DOM. GSAP and resolveSelectorElementIds both resolve against that same DOM so they agree, but a runtime that injects wrapper elements between authored ones would shift the index relative to the authored source.

…me percentages

A ruler press with no pointer movement settled the playhead at t=0 instead
of the clicked time. handlePointerUp replays pendingClientXRef, which only
the pointermove path wrote, so a plain click fell back to the ref's initial
0 and overwrote the correct pointerdown seek. Seed the ref on pointerdown.

The keyframe retime move branch also returned the raw quotient while the
resize branch rounded to 3dp, so values like 74.81203007518799% landed in
the user's source and churned the diff on every drag. Round at the point of
computation so the no-op test and the written value agree.
The 24x24 WCAG 2.5.8 overlay sits on a wrapper that outranks the diamonds,
so on a segment narrower than 24px it overhung them and won their hit test at
fit zoom. Gate the overlay on the clear span between the two diamonds and let
the button keep its 16x16 box below that.

Also renames the pointer target suite to say it asserts the classes that
produce the size, not the measured geometry, which happy-dom cannot see.
…ide press

Split hex-draft ownership so the hex input is the sole author of its
own text while editing (updateColorDraft no longer stamps a canonical
hex back over every keystroke), fixing snap-back on backspace and the
silent wrong-colour clobber on non-repeating hex values. Route hex
typing through the shared gesture transaction so outside-click and
Escape settle/cancel it like the other inspector fields, instead of
relying on a private onBlur commit that never fires once the panel
unmounts on outside-click.
Swap the panel's hand-rolled bubble-phase mousedown listener for the
shared useContextMenuDismiss hook, which adds Escape support and fixes
outside-click dismissal when a canvas gesture (e.g. marquee start)
calls preventDefault on pointerdown, which otherwise suppresses the
mousedown compat event entirely. Also wires up dialog ARIA (role,
aria-modal, id/aria-controls) between the trigger and panel.
The panel does not trap focus and leaves the rest of the editor operable,
so aria-modal would tell assistive tech the whole app is inert while it is
open. role=dialog plus aria-controls and Escape is the correct non-modal
disclosure shape.
The gesture resolver gated on six digits while parseCssColor accepts both
lengths, so #F00 previewed as nothing and committed nothing. The old onBlur
path parsed it, making this a behavioural loss rather than a pre-existing gap.

Also asserts that an incomplete hex is restored on outside-click, not merely
left uncommitted.
A sub-composition tween's resolvedStart is composition-local, while the timeline
element resolved for it is the sub-comp HOST, whose start is main-timeline
absolute. toClipPercentage subtracted the two frames from each other, so a host
mounted at 1.5s cached its 0s tween at -12% and its last tween's end keyframe at
88% instead of 100%. A clip-relative percentage can never be negative.

resolveClipTimingBasis now returns the clip start in the frame the tween's own
times are measured in: the composition mount (expandedParentStart for an
expanded child, the parent composition clip's start otherwise, 0 for a
root-composition element) is subtracted, and a sub-comp inner element that falls
back to its host's window starts at 0 in that window. It moves to gsapShared so
the post-commit cache writer can share it instead of resolving its own basis,
which also gives that writer the sub-comp host fallback it was missing.
buildExpandedElements synthesized DOM-only sub-composition children against
the top-level element rather than the parentHost it resolves immediately
after. Under two-level nesting every child row therefore inherited the
top-level window instead of its own host's, so the rows drew at the wrong
offset and duration.
The sub-composition drill-in replaced the host row with its children. Since
expansion is also driven by the playhead alone (paused auto-expand), an
ordinary seek into a sub-composition made the host row disappear, taking its
keyframe lane with it: diamonds render per row from keyframeCache.get(
elementKey), so no row means no diamonds. Reproduced live with no drag at
all, seek 0 gave 3 diamonds, seek 7.68 gave 0, seek 0.2 gave 3.

Make the expansion additive instead. The host row stays and its children are
appended directly below it. The synthetic fractional lanes already used for
children sit strictly between the host's lane and the next integer, so the
host keeps its own row without colliding with anything.

The time-keyed auto-expand itself is unchanged.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-lane-tween-attribution branch from 61a7d36 to 00df2e3 Compare July 28, 2026 16:09
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from ff16b42 to 295f98b Compare July 28, 2026 16:09
Drilling two levels deep spared only the top-level row, so the middle host
lost its row and its keyframe lane with it. Spare every host between the
drilled one and the top, and anchor the children under the deepest host that
actually has a row.

Also stops resolveClipTimingBasis handing back a main-timeline start when a
clip names a parent composition that is absent from the element list. The
mount is unknowable there, so the child's own window is the only safe frame.
Two halves of one inversion in the expanded timeline lanes: the tweens
that should show were filtered out, and a tween that should not be there
was the only survivor.

Lane classification read the parser's whole-tween verdict, which is
undefined for anything spanning more than one property group. `{x,
opacity}` is the canonical HyperFrames entrance tween, so five of the
seven tweens in the swiss-grid graphics example had no caret, no
reserved row and no diamonds. Classify per property instead, through one
helper both the rendered lanes and the reserved row heights count
through so they cannot drift again.

Attribution matched an unanchored leading id, so `#stat3 .block` was
filed under `#stat3`. The child's diamonds landed on its ancestor and
collided with the ancestor's own tween at the shared percentage, which
the same-percentage merge then resolved by dropping the ease. Route
attribution through resolveSelectorElementIds, which anchors a
whole-selector id and otherwise resolves through the live preview DOM,
and anchor its no-DOM fallback so a descendant selector resolves to
nothing rather than to its ancestor. The merge rule is unchanged.

Also brings the last property-lane call site onto the shared clip timing
basis: an expanded sub-composition child's start is host-absolute while
its tweens are local to its own file.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-lane-tween-attribution branch from 00df2e3 to 59a818e Compare July 28, 2026 17:03
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from 295f98b to 37140af Compare July 28, 2026 17:03

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 37140af.

Clean, well-scoped fix for a real data-loss bug. writeTargetSelector's rung ladder (#id[data-hf-id] → already-unique own selector → :nth-child structural → bare-selector fallback) matches the identity semantics the runtime uses for read-back, and the new gsapShared.writeTarget.test.ts exercises the actual write→parse→attribute round trip against a real jsdom + parseGsapScript + addAnimationWithKeyframesToScript, which is exactly the shape that failed before. The 5-siblings collapse regression is directly asserted.

CI note — the failing preview-regression gate is CI infra noise, not a real regression. PREVIEW_PARITY_RESULT: cancelled (preview-parity got cancelled, likely superseded by a rebase) cascaded into the gate treating != "success" as failure — same shape as [[feedback_ci_shard_cascade_noise]]. A retrigger should clear it.

Cross-cutting theme in the inlines: the PR body explicitly scopes the fix to two writers (useEnableKeyframes no-anim branch + commitKeyframeAtTimeImpl) and defers six more to a top-of-stack sweep. That's fine as a strategy, but three other paths inside the same file — the replace-with-keyframes mutations at useEnableKeyframes.ts:156, 262, 361 — do the same bare-selector write, and the PR body's rationale ("retargeting a tween the author aimed at a whole group is a different decision from adding a keyframe") applies to authored group-tweens but not obviously to the case where the target was already idless. Worth clarifying whether those three are in the deferred-follow-up bucket or genuinely intentional.

Nothing blocking. LGTM from my side.

Review by Rames D Jusso

Comment thread packages/studio/src/hooks/useEnableKeyframes.ts
Comment thread packages/studio/src/hooks/gsapShared.ts
Comment thread packages/studio/src/hooks/gsapShared.writeTarget.test.ts

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 review — writeTargetSelector decouples write-target from match-target

Framing. The new-tween write path was authoring through selectorFromSelection, the same helper the match path (findTweenAtTime) needs to keep returning its exact string. For an id-less element that helper returns whatever buildStableSelector produced — a bare class like .group — so authoring against 1 of 5 siblings wrote a tween GSAP resolved to all 5 and that resolveSelectorElementIds read back as all 5, collapsing their timeline rows into one. writeTargetSelector is the write-side counterpart: #id[data-hf-id="…"] → the selection's own selector when already unique → a :nth-child chain anchored on the nearest identifiable ancestor, each rung verified against the live DOM. Two authoring sites are wired (the no-animation branch of useEnableKeyframes, and the add-with-keyframes mutation in commitKeyframeAtTimeImpl); the match-path use of selectorFromSelection in commitKeyframeAtTimeImpl is preserved and its comment names why, which is the load-bearing decision here. replace-with-keyframes intentionally still writes the selection's own selector — retargeting a whole-group tween is a different user decision. All correct in shape.

Findings

P2 — Ponytail fallback re-introduces the exact broken selector for the edge case the guard-comment says it excludes.

  • packages/studio/src/hooks/gsapShared.tswriteTargetSelector, final line: return selection.selector ?? null;
  • The block-comment above says "it is only reachable with no live DOM to disambiguate against." But with element && doc truthy we can still exit the if (element && doc) block when both matchesExactlyOne(doc, selection.selector, element) returns false AND structuralSelector(element) returns null. structuralSelector returns null in at least these live-DOM cases: element has no parentElement (root / detached / hosted across a shadow-root boundary, where parentElement returns null); or its final matchesExactlyOne on the built chain fails. The realistic one is detached-between-select-and-commit: user selects a class-only element, some code re-parents or removes it before the K press commits — element.ownerDocument still hands back a document but the walk short-circuits, and the ponytail returns .group unchanged. That is a round-trip through the exact bug this PR is fixing.
  • Failure mode: useEnableKeyframes already has an if (!selector) no-op fallback to session.handleGsapAddAnimation("to") — a graceful fallback, not a silently dead button. Returning null when the DOM was present but structural failed would route through that same guard. Similarly, commitKeyframeAtTimeImpl's outer writeTargetSelector(selection) ?? selector would fall back to selectorFromSelection — same broken bare class either way, but at least the branching invariant would be honest.
  • Suggested tightening: return null from writeTargetSelector when element && doc was true but structural failed. Keep the raw-selection.selector return for the !element || !doc branch only, and update the comment to match. If the design intent really is "prefer a broken selector over a no-op," say so — but the current comment overstates the guard.

P2 — Test coverage gaps around the structural walk and the ponytail.

  • packages/studio/src/hooks/gsapShared.writeTarget.test.ts covers #id, data-hf-id, unique class, and 5-class-siblings-under-#scene (a two-step walk with the identifiable ancestor at depth 1). The round-trip test adds ids after selection to prove single-element attribution — good, that's the load-bearing behavioral assertion.
  • Missing: a deep chain with a mixed-identity ancestor (#outer > div:nth-child(2) > [data-hf-id="mid"] > .child:nth-child(3)) — the unshift-and-break identity handoff is where a regression will hide.
  • Missing: selection.selector that is invalid CSS (digit-leading id, unbalanced bracket) — matchesExactlyOne's try/catch swallows the SyntaxError and the "fall through to structural" contract deserves a lock, especially since idSelector already exists to handle digit-leading ids elsewhere in this file.
  • Missing: the ponytail path itself. Whatever the resolved behavior is (per finding 1), the current suite has no assertion covering "structural returns null with a live DOM."

P2 — commitKeyframeAtTimeImpl's outer writeTargetSelector(selection) ?? selector fallback is (effectively) dead.

  • packages/studio/src/hooks/gsapKeyframeCommit.ts, add-with-keyframes mutation: targetSelector: writeTargetSelector(selection) ?? selector.
  • writeTargetSelector returns selection.selector ?? null in its ponytail and always returns a non-null string for the #id / data-hf-id branches; the outer selector is selectorFromSelection(selection), which is idSelector(selection.id) ?? selection.selector ?? null. Given we already returned early on !selector, the only way for writeTargetSelector to return null while selector is truthy is selection.id set with no selection.selector and no element/doc — but the selection.id branch of writeTargetSelector fires first and always returns. So ?? selector is defensive dead code today. Not a bug — a // keeps the invariant explicit even if writeTargetSelector loosens later comment (or dropping it) would read cleaner.

Not-blockers, worth flagging

  • Six more write sites reach the same defect (add-animation button, drag, resize, rotate, gesture recording, property panel), scheduled for the top of this stack. Fine — but each of those six needs its own "wrote a class-only sibling, read back one element, timeline row survived" behavioral test alongside the swap. A helper swap without that assertion is where the top-of-stack PR is most likely to miss a site.
  • replace-with-keyframes intentionally keeps selectorFromSelection. The design call is clear (retargeting a whole-group tween is a different user decision), but nothing locks the intent — worth a test that asserts the retarget path still produces the group selector so future refactors don't drift silently.
  • The :nth-child "known ceiling" (a runtime that injects wrapper elements shifts the index against the authored source) is called out in the body. Acknowledged.
  • structuralSelector uses Array.prototype.indexOf.call(parent.children, node) — works fine, but [...parent.children].indexOf(node) reads cleaner. Nit.

Verdict

COMMENT. Right shape, right sites, correct preservation of the match-path selector, behavioral round-trip tests through the real writer. The ponytail-vs-guard-comment mismatch is the one worthwhile follow-up: it silently re-enables the bug in a narrow but plausible detached-element window, and the fallback path in useEnableKeyframes is already there to handle a null return safely.

Grade: B+ — correct and well-tested for the primary scenario; the ponytail edge case and a couple of walk-coverage gaps prevent an A.

Review by Via

animIdForProp matched on the parser's whole-tween propertyGroup, which is
undefined for a legacy mixed tween such as {x, opacity}. Such a tween never
matched, so an edit to either property fell through to the selection's
default animation, a different tween than the lane the user is editing.
Resolve through animationLaneGroups, the same per-keyframe helper the
rendered lanes and the reserved row heights already count groups with.
"Add keyframe at playhead" on an element with no id authored the bare class
buildStableSelector hands back, so one add on a `.group` wrote
`tl.to(".group", ...)`: a tween that animates all five siblings and that
resolveSelectorElementIds reads back as all five, collapsing their timeline
rows into one. It survived a reload, so the written file stayed un-editable.

writeTargetSelector is the write-side counterpart to selectorFromSelection
(which must keep returning the exact string findTweenAtTime compares against).
It resolves the element's own identity to a selector that addresses exactly
one element: `#id`, else `[data-hf-id="..."]`, else the selection's selector
when it is already unique, else a `:nth-child` path anchored on the nearest
identifiable ancestor (the selector + selectorIndex pair, resolved through the
DOM the index was counted in).

Applied to the two paths that author a NEW tween: the no-animation branch of
useEnableKeyframes and commitKeyframeAtTimeImpl. replace-with-keyframes still
writes the selection's own selector, since retargeting a tween the author
aimed at a whole group is a different decision from adding a keyframe.
writeTargetSelector returned the selection's bare selector whenever the
structural walk failed, including when a live DOM was there to check
against. An element detached between selecting and committing takes that
path, so the add re-authored the exact `.group` string the function exists
to replace. Return null instead: a failed walk against a live DOM is
evidence, not absence of it. Callers that cannot drop a user edit opt back
in with `?? selectorFromSelection` where the trade is visible.

The replace-with-keyframes paths had the mirror defect. The server deletes
and re-adds the tween, so their target string is a full rewrite, and they
derived it from the selection: promoting a set on a tween already narrowed
to `#scene > div:nth-child(3)` widened it back onto every class sibling,
undoing the narrowing an earlier add had made. They now keep the tween's
own authored target, matching what eight sibling commit modules already do.
@miguel-heygen
miguel-heygen force-pushed the fix/studio-playhead-keyframe-target branch from 37140af to f04cdb7 Compare July 28, 2026 18:39
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

All findings from both reviews are addressed at f04cdb79. Per-thread detail is inline; the summary and the two findings that had no thread:

Ponytail fallback (P2, both reviewers). Confirmed by probe, not by reading: detaching the element between select and commit leaves ownerDocument resolving, parentElement null, and the old code returned ".group" verbatim. writeTargetSelector now returns null when a live DOM was available and no rung addressed one element. Callers decide; the docstring says so.

Test gaps (P2, Via). Four added: deep mixed-identity chain, invalid-CSS fall-through, the null-with-live-DOM path, and the no-DOM path that still returns the raw selector.

?? selector in commitKeyframeAtTimeImpl (P2, Via). Your dead-code analysis was correct for the old implementation. It is live now, by design, since writeTargetSelector can return null on the very path that reached it. Rather than drop it I gave it the comment you asked for: that branch has no graceful no-op, so it takes the author's own selector over dropping the keyframe the user just asked for.

replace-with-keyframes (Rames). Not deferred, and not intentional. Probing showed those paths re-widen a tween a previous add had already narrowed, so they undo this PR's own guarantee on the next edit. All five sites in the file now keep the tween's own target. Detail in the thread.

indexOf nit (Via). Taken, now [...parent.children].indexOf(node).

Six remaining writers. Still at the top of the stack (#2849), and your point about each needing its own behavioural assertion rather than a bare helper swap is well taken; that is how the sweep is tested there.

Not taken: dev-only console.warn on the fallback (Rames). The null return is the signal now, and gsapShared has no dev-mode helper today, so this would add a logging seam for a path a test can already reach.

Not taken: a test locking the replace path to the group selector (Via). That behaviour turned out to be the bug, so locking it would have cemented the re-widening. The test locks the opposite invariant instead.

Studio suite 3042 passing on this branch, typecheck / oxlint / oxfmt clean.

vanceingalls
vanceingalls previously approved these changes Jul 28, 2026

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 review — all three R1 P2s resolved; scope expanded to the mirror bug on replace paths

Reviewed at f04cdb79c5f1475fa7d6048036a4e0af354a60a2.

Delta since R1 (37140aff): force-push; single new commit f04cdb7 on top of fd5555b. packages/studio/src/hooks/gsapShared.ts more than doubled its additions (+50 → +105). New export existingTweenTargetSelector factored out; three call-sites in useEnableKeyframes.ts rewired; gsapShared.writeTarget.test.ts now covers 12 cases including the round-trip and the commitKeyframeAtTimeImpl end-to-end.

R1 finding verdicts

R1 P2 Verdict
#1 — Ponytail fallback reintroduces the bug in writeTargetSelector RESOLVED. The live-DOM branch (lines 260-264) now gates on element && doc and returns structuralSelector(element) directly — a failed walk against a live DOM returns null rather than falling through to the bare selector. The last-rung return selection.selector ?? null at line 266 is now only reachable with no DOM to disambiguate against. Detach reproducer in the test at :141 pins the corrected behavior.
#2 — Test coverage gaps RESOLVED. Deep mixed-identity chain at :107 (walk stops at data-hf-id="mid", does not keep climbing to #outer). Invalid-CSS selection.selector fall-through at :131 (the exactly-one rung reads as a miss rather than crashing). Ponytail-path assertion superseded by the null-return case at :141 — which is what the corrected code produces.
#3 — Dead ?? selector fallback in commitKeyframeAtTimeImpl ⚠️ MITIGATED / DEFUSED by #1. Once writeTargetSelector returns null on a failed structural walk against a live DOM (the fix for #1), the ?? selectorFromSelection(selection) at gsapKeyframeCommit.ts:75 becomes reachable AND necessary — this is the add-with-keyframes path that has no graceful no-op to fall to, so it takes the author's own selector, group collapse and all, over dropping the keyframe. The inline comment at :71-74 makes the trade visible. Not a defect at the new head.

Fresh adversarial pass at new head

  • existingTweenTargetSelector addition (gsapShared.ts:279) — the mirror bug Rames flagged on the three replace-with-keyframes paths. Gate is correct: animation.targetSelector && !animation.hasUnresolvedSelector preserves an already-narrowed tween; only an unresolved target falls back to the selection. Consumer sites (extendKeyframedTweenToPlayhead, promoteSetToKeyframes, replaceSetWithSingleKeyframe) all switched from selectorFromSelection(sel) to existingTweenTargetSelector(anim, sel) and continue to short-circuit on !selector.
  • Round-trip test (writeTargetSelector — write/read round trip, :164) — the strongest guarantee this fix could carry: the written selector re-parses and resolveSelectorElementIds attributes the tween to exactly one element, not five. Locks the bug from recurring at the read side.
  • End-to-end coverageuseEnableKeyframes.test.ts:470 asserts against the actual product contract ("targets the selected sibling alone, not every element sharing its class"), which is what the user would notice if this regressed.
  • No new P0/P1 defects surface. CI is mixed only because the force-push cancelled prior runs; the in-flight Graphite mergeability check is expected to settle. No dependent stack invariants broken.

Grade: A. Fixed both what R1 asked for and, cleanly, the mirror defect Rames flagged — not by widening the ponytail but by factoring out a separately-named helper with its own docstring and tests. Design intent is now readable at both write-target contracts side-by-side.

Verdict: APPROVE.

Review by Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R2 reviewed at f04cdb7 (delta from 37140af).

Clean R2, no blockers. Every open R1 finding is resolved and the new existingTweenTargetSelector helper is a nicer design than my original suggestion — routing the replace-with-keyframes retargets through it preserves author intent (a tween the author aimed at a whole class group stays aimed at the group after promotion), where wiring writeTargetSelector into those paths would have silently narrowed them.

Resolved from R1:

  • useEnableKeyframes.tsextendKeyframedTweenToPlayhead, promoteSetToKeyframes, and applyArcKeyframeAtPlayhead now go through existingTweenTargetSelector. The docblock explaining the write-vs-retarget distinction is load-bearing and easy to read.
  • writeTargetSelector bare-selector fallback semantics rewritten: null-on-DOM-miss (structural walk failed with a live document to check against) rather than handing back the input selector. Callers that can't afford to drop a user edit opt back in explicitly with ?? selector, which makes the trade visible at each site (see commitKeyframeAtTimeImpl comment). This is stronger than a dev-only warn — the null result forces callers to make the decision.
  • Deep-tree structural coverage: the new "hands identity back to a data-hf-id ancestor part way up a deep chain" test exercises exactly the mixed-identity walk the R1 nit was after. The invalid-CSS-selector-fall-through and detached-element-returns-null tests are welcome bonuses.

Observation, not blocking: existingTweenTargetSelector returns anim.targetSelector verbatim when !hasUnresolvedSelector, which means a legacy set written with a bare class (from before this whole fix) stays bare on promotion — the mis-attributed-to-siblings behavior survives for pre-existing tweens. This is consistent with the PR body's "retargets of an EXISTING tween are deliberately untouched" carve-out; flagging as the natural follow-up seam if a migration story for legacy tweens is on the roadmap.

Small polish: [...parent.children].indexOf(node) reads much cleaner than the old Array.prototype.indexOf.call form.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen changed the base branch from fix/studio-lane-tween-attribution to main July 28, 2026 19:42
@miguel-heygen
miguel-heygen dismissed vanceingalls’s stale review July 28, 2026 19:42

The base branch was changed.

@miguel-heygen
miguel-heygen merged commit b635e2f into main Jul 28, 2026
49 of 59 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-playhead-keyframe-target branch July 28, 2026 19:57
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…d-keyframe-target

fix(studio): target one element when adding a keyframe at the playhead
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants